Skip to content

fix(driver-memory): honour DriverOptions.tenantId on the read path — a scoped read no longer returns other organizations' rows - #16733

Closed
os-musk wants to merge 6 commits into
mainfrom
claude/issue-16589-memory-driver-tenant-scope
Closed

fix(driver-memory): honour DriverOptions.tenantId on the read path — a scoped read no longer returns other organizations' rows#16733
os-musk wants to merge 6 commits into
mainfrom
claude/issue-16589-memory-driver-tenant-scope

Conversation

@os-musk

@os-musk os-musk commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16589

Clause-②: yes

Authority — the director seat's ruling, ⛔ not #6915's Route A

This change is read-side, row-level tenant isolation on driver-memory, which is the shape #6915 calls Route A. ⛔ It is not offered as sanctioned by #6915. That card's maintainer ruling (5261729371, 2026-08-12) chose 处置 B and wrote 「⛔ 不做处置 A(实现行级租户隔离):#5499 投资冻结继续有效」. An earlier revision of this body described the change as "the read half of #6915's Route A" without saying that Route A is the route that ruling declined — the contract review (5578985922, F1) is right that this presented a declined route as an approved one, and it is corrected here.

What authorises it is the director seat's ruling on the card, comment 5579651209 (decision batch #84, 2026-09-08). Verbatim: "option 2 stands: read-side row-level tenant scope on driver-memory is permitted". Its reasoning: #6915's exclusion of A rested on one stated ground, the #5499 freeze, which the maintainer dissolved on 2026-08-11 (5252526378 — "fully dissolved … no longer gates anything"); with the ground gone the standing meta-criterion decides (「一个操作两个实现且行为不一致 ⇒ 带治理的一侧胜出」), and the governed side is driver-sql's enforced scope. The same ruling records that the #6915 boot guard is not weakened — walled postures and tenancy.enabled: true stay refused, write-side stamping stays out — so this removes the one silence #6915 left rather than reversing its purpose.

⛔ Still draft, still needs:contract-review: nothing in this round marks it ready, queues it or touches its labels.

The defect

Two predicates decided "is this object tenant-scoped", and they disagreed on the default case.

  • The engine scopes unless the object opts OUTEngine.buildDriverOptions in packages/objectql/src/engine.ts, at hasTenant: execCtx?.tenantId !== undefined && !isTenancyDisabled(objectSchema) && !isFederated.
  • The memory driver refused only an explicit opt-INdeclaresTenantScope in packages/drivers/driver-memory/src/memory-tenancy-guard.ts: (schema as ...)?.tenancy?.enabled === true.

So an object that omits the tenancy block — the common case — was scoped by the engine and invisible to the boot guard, and the driver then did nothing with the scope. Memory-driver runs returned cross-organization rows a SQL driver refuses, and neither driver said a word.

The accurate way to state the absence: the read path knew nothing about tenants; the unique-constraint path did. memory-driver.ts itself measured 0 hits for tenantId|tenantIds|organization_id; the package as a whole measured 82 matching lines across 4 of 56 files, all of them docblock prose plus one live organization_id key check in memory-unique-constraint.ts about UNIQUE composites — a different concern.

What this changes

memory-tenant-scope.ts is the read half, with SqlDriver.applyTenantScope's semantics read off the SQL driver and reproduced arm for arm rather than invented:

fact driver-sql here
no tenantId (undefined / null / empty) builder untouched no predicate
object has no tenant column builder untouched no predicate
non-empty tenantIds (ADR-0105 D2) col IN (...) OR col IS NULL membership OR global
otherwise col = :tenantId OR col IS NULL equality OR global

The NULL arm is the #2734 global-row carve-out and it is load-bearing, not lenient: a row with no organization belongs to no OTHER tenant, and strict equality once made every tenant admin read zero RBAC rows on a fresh deployment. In this store the absence of the key is that same fact.

The chokepoint, and the four doors that are NOT behind it

Every door that selects rows routes through one chokepoint: find, findOne, count, aggregate (both arms), update, upsert, delete, updateMany, deleteMany, bulkUpdate, bulkDelete.

Four methods take a DriverOptions and are deliberately not routed through it. They are named rather than left under a claim that does not hold for them (contract review F4 — an earlier revision said "every door that accepts a DriverOptions", which is literally false for these four):

  • create and bulkCreate — the insert doors. driver-sql scopes those through injectTenantOnInsert, not applyTenantScope, and the write half this PR leaves out is exactly the stamp they would need.
  • syncSchema and dropTable — DDL. driver-sql scopes neither.

distinct() is the one selecting door that stays unscoped, and it is named rather than left to be found: its signature (object, field, query?) accepts no DriverOptions, so a caller has nowhere to pass a tenant. driver-sql's distinct does scope. Nothing in this repository calls driver.distinct(), so widening the signature would add a parameter no producer supplies. It has a pin of its own in the suite.

Id-addressed doors, including upsert

Id-addressed doors land on their own existing "not found" contract rather than inventing a cross-tenant refusal shape — and upsert is now genuinely inside that promise (contract review F2, required by the ruling).

An upsert addressed by an explicit id whose row exists in the table but outside the caller's scope refuses; it does not fall through to create. id is this store's primary id and create checks only DECLARED unique constraints — id is not one, which memory-bulk-create-atomicity.test.ts already records — so falling through left the table holding two rows with one primary id, which then corrupts every id-addressed door for both tenants (update/delete take the first matching index; deleteMany rebuilds from a matched-id set). Before this PR that call overwrote the other organization's row, which was the original defect; the first revision of this branch traded it for a duplicate id.

driver-sql is not the precedent for falling through, and this branch's code comment wrongly said it was. SQL's INSERT … ON CONFLICT(id) merges on the PRIMARY KEY regardless of tenant — its own docblock: "the verdict itself is tenant-independent regardless: id is the PRIMARY KEY, so at most one row in the table can carry it" — and only the readback is scoped. A second row with one id is unreachable there. The comment is corrected in this round.

The refusal is raised in strictMode and outside it alike, and that asymmetry with update/delete is deliberate: upsert's declared return is a bare record promise — Promise of Record of string to unknown, spelled without the angle brackets so this body survives GitHub's sanitizer — with no miss arm (#13878), so the quiet non-strictMode miss (null for update, false for delete) is not expressible here. The two alternatives were widening this door's declared return with an arm no caller was ever asked to narrow, or landing the duplicate id. Both are worse than throwing the same "not found" message the other id-addressed doors throw.

Write-side

Write-side tenancy is deliberately not included. Nothing stamps a tenant column on insert the way SqlDriver.injectTenantOnInsert does, so a row created without an explicit organization lands org-less and is then global by the rule above. That is why the boot guard still refuses a walled posture and still refuses an object declaring tenancy.enabled: true, and the guard's refusal message now names unstamped writes as the half that is genuinely missing.

The residual exposure is org-less rows, which is the #2734 semantics: a row is hidden from a caller only when it carries a different, non-null organization, identical to SQL. An unstamped own write lands org-less and stays visible to its author.

The docstring correction

declaresTenantScope's load-bearing sentence — "every object in a single-tenant deployment omits the block" — was false, and it is recorded in place rather than quietly deleted. single constrains the wall, not the number of organizations: the reporting run held 13 sys_organization rows under a single posture (twelve seeded by the app, one the platform mints for the admin), and rows carry whichever organization_id they were written with.

The guard's head docblock and its refusal message carried the same claim ("never reads DriverOptions.tenantId") and are corrected with it.

The control can fail, and here is the measurement

src/memory-tenant-scope.test.ts, 25 cases (23 before this round; F5 added two). The fixture seeds two organizations plus an org-less row, so "returns nothing", "returns everything" and "returns the right subset" are three distinguishable answers, and every scoped case asserts the caller's OWN rows still come back. Asserting only the "after" half would have reproduced this card's own subject.

The two pins added this round:

  • upsert by an id that exists outside the scope refuses, and the table still holds exactly one row carrying that id — the assertion the whole finding is about. Two positive controls sit beside it so it reads as "the cross-wall id is refused" rather than "upsert by id is broken": inside the organization the same door still MERGES, and an id no row in the table carries still INSERTS.
  • deleteMany under scope WITH a where. Only the delete-all arm was exercised before; the where arm filters visible, collects matchedIds and rebuilds the whole table from that set, which is exactly where a duplicate id would cross tenants. The filter matches one row in each organization, so "scoped" and "unscoped" are two different numbers (1 vs 2), not just two row lists.

Two-leg ablation, InMemoryDriver.tenantScope forced to null, rebuilt, marker verified present in dist/ before the run — taken on the pre-patch head 40300f0107 and not re-run this round:

LEG 1 (mutated)   Tests  16 failed | 7 passed (23)
LEG 2 (restored)  Tests  23 passed (23)

Restore was blob-verified, not eyeballed: git checkout HEAD -- THE-MUTATED-FILE (never a bare git checkout --, which reads from the polluted index), restored blob 2e6c7b1c3d equals the HEAD blob 2e6c7b1c3d, git diff HEAD empty, whole-tree git status --porcelain empty, and ablation-dist-preflight --absent confirms the marker gone from all 6 built files.

The 7 that stayed green are accounted for, not waved through. Five are negative controls whose subject IS the unscoped answer (no tenantId; tenancy.enabled: false; no tenant column; the sticky opt-out; unscoped distinct()) — a mutation that forces "no scope" cannot redden a case that expects no scope, and those are the half that catches OVER-scoping. Two test the pure tenantScopePredicate, a layer the chokepoint ablation deliberately does not touch.

An eighth case was green for a bad reason and the ablation is what found it: with only two organizations seeded, the union case's [ORG_A, ORG_B] covered the whole table, so "the union widened the scope" and "no scope ran" were the same answer. It carries its own third organization now, and reds under ablation.

⚠️ Every isolation measurement previously taken on this driver is void and has to be re-taken — including the app that reported this. That is part of the acceptance, not a footnote.

Evidence boundary, reconciled

The card quoted this tree's source and measured counts against the published @objectstack/cli 17.3.0 runtime. Reconciled rather than assumed: @objectstack/cli 17.3.0 depends on @objectstack/driver-memory and @objectstack/objectql at ^17.3.0, and 17.3.0 is the newest published version of both, so the app ran exactly those. The published driver-memory 17.3.0 tarball carries declaresTenantScope as schema?.tenancy?.enabled === true and its .d.ts carries the false docstring sentence verbatim; tenantId|tenantIds|organization_id appear in its runtime only inside the refusal message and the unique-constraint organization_id key check. The published runtime and this tree agree on all four premises.

Verification

This patch round, on head bb394b3fef (branch merged with origin/main first, plain merge, no rebase)

  • pnpm --filter @objectstack/driver-memory test45 files, 1120 passed, 15 todo (1135), exit 0. That is 1118 → 1120: the two F5 pins. The 15 todo are unchanged and none are in the tenancy path.
  • pnpm --filter @objectstack/driver-memory typecheck — both legs (tsc --noEmit && tsc --noEmit -p tsconfig.typecheck.json), exit 0.
  • node scripts/check-empty-changeset.mjs --base origin/main — exit 0: "No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)."
  • node scripts/check-changeset-no-major.mjs --base origin/main — exit 0: "This diff introduces no major bump." ⚠️ Its LEVEL axis reports NOT MEASURED locally — "no pull_request payload was available to read a declaration from" — which is neither a pass nor a failure (check:react-declaration-parity 是唯一没接进任何 workflow 的源码审计门禁,且无 MANIFEST 时静默 skip 退出 0 —— 它现在永远不可能红 #4690). CI runs it with the payload.
  • node scripts/check-adr-0087-registration.mjs --base origin/main — exit 0: "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", listing .changeset/memory-driver-read-side-tenant-scope.md [BREAKING] not-required (no-migration-prescription). ⭐ That line is itself the F3 evidence: before this round the gate saw no declaration to judge, and it now judges one and passes it.

From the pre-patch head 40300f0107, not re-run this round

  • pnpm lint (eslint . --no-inline-config) — exit 0 over the whole repository: 6338 files, 0 errors, 0 warnings, read from --format json. Not narrowed.
  • Consumers of this package: @objectstack/runtime 240 files / 3340 tests, @objectstack/cli --project unit 184 files / 2509 passed + 6 expected-fail, @objectstack/service-datasource 32 / 676, @objectstack/plugin-dev 7 / 72 — all green. @objectstack/cli's integration tier is declared to CI: this diff touches no spawn entry point and no driver/kernel start-up path.
  • Gates derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack and reconciled with --ran, verbatim: ✓ dispatch-gates --ran: 56 derived famil(ies) accounted for — 56 run, 0 NOT-MEASURED.

⚠️ Stated rather than glossed: this round re-ran the package's own suite, its typecheck and the three changeset gates on the new head. The repository-wide lint, the consumer suites and the 56-family gate sweep were measured on 40300f0107 and are not re-measurements of the current head; the patch touches three files inside one package, and CI is the run that answers for the rest.

Changeset

minor on @objectstack/driver-memory, with both breaking-ness carriers present (contract review F3, required by ruling 5579651209).

Level. minor is unchanged and required twice over: the 2026-09-04 maintainer ruling (decision batch #35, on #15294), written out in pr-automation.yml's "WHICH LEVEL" prose — "A purely additive widening of a published package's public surface (a new exported symbol on an index, a new accepted key or value) takes at least minor" — and this PR adds recordTenantField, tenantScopePredicate and TenantRowPredicate to the package index.

The two carriers, added this round:

  • The BREAKING banner in the changeset body, in the double-asterisk spelling check-changeset-no-major.mjs names and check-adr-0087-registration.mjs matches. During the launch window the bump level is not the carrier at all — check-changeset-no-major.mjs forbids major outright — so, in that file's own words, the banner and the ADR-0087 disposition "are not documentation niceties — during the window they are the only signal there is". A caller passing tenantId now receives fewer rows, and this package's own published docblock told that caller the driver never reads DriverOptions.tenantId: the narrowed accept set was documented behaviour, not merely a defect.
  • An ADR-0087 disposition marker, spelled as the gate parses it, claiming not-required (no-migration-prescription). Nothing here is retired or renamed — no authorable key, no option, no exported symbol removed — so the ledger has no upgrade path to serve, and the affected consumer is reached by the changeset body and by the driver's corrected refusal message.

The governing precedent is #7924, and it is the same package on the same subject. #6915 / PR #7924 shipped as feat(driver-memory)!: with an ADR-0087 not-required (no-migration-prescription) disposition, on the reasoning recorded in the driver-memory CHANGELOG's 17.x entry 45d5bd2: "a refusal that was always owed, but it is still a behavior change, and the release notes must be able to say so". That is this change's shape exactly.

Withdrawn. An earlier revision of this body argued for no carriers on three readings, the closest of which cited .changeset/session-unbacked-org-claim-dropped.md (@objectstack/core, patch). That changeset is pending stock, not released precedent — the PM flagged it and the contract review confirmed it. It is no longer offered as a basis, and the "no carriers" reading is withdrawn in full.

验收备注

Filed#16729: on this driver a partial syncSchema re-registration (a schema with no tenancy block) silently flips a platform-global object's UNIQUE partition from global to per-organization, because tenantFieldOf reads only the schema it is handed while driver-sql keeps a sticky opt-out record for exactly that shape (#3249). Reproduced at the export boundary against the built artifact. It is not a regression from this PR: this PR adds the sticky record for the READ scope only, deliberately, because making the uniqueness path sticky in the same stroke changes uniqueness semantics (#13197 / #13239 territory) and needs its own test. The card says so, and says to close the gap by routing uniqueness through the same record rather than by removing the read-side one.

Noted, not filed:

  • scripts/check-tenant-chokepoint.mjs cannot re-derive the new chokepoint. Its criterion is this.getBuilder(object, options), the single constructor of every knex query in the SqlDriver family; this driver filters an array and builds no query, so there is nothing for the gate to key on. Its scope paragraph stays accurate and is left alone; the in-memory doors are held by the new suite instead. A coverage limit, not a violated contract.
  • distinct() stays unscoped here while driver-sql's is scoped, for the structural reason above. No producer in this repository calls it.
  • The NULL-tenant global-row carve-out this mirrors is itself under review — #15212 (ADR-0131 D1/D8/D9, protocol 18) proposes retiring both orWhereNull arms and the __global__ sentinel, and #13564 is the census behind that decision. This PR reproduces today's shipped driver-sql semantics on purpose; if that ruling lands, both drivers move together and this is one more call site, not a second dialect.

Draft only, per the dispatch: never ready, never auto-merge, never queued.

The patch-round figures above were taken on bb394b3fef; every figure explicitly attributed to 40300f0107 was taken there and is not restated as current. Patch round delivered from session https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH; the original round was delivered from session_01ADLdAs2pVcH17h9tZKWMBg.

The engine scopes an object unless it opts OUT (`tenantId !== undefined &&
!isTenancyDisabled(schema) && !isFederated`); this driver's boot guard refuses
only an explicit opt-IN (`tenancy.enabled === true`). An object that omits the
`tenancy` block — the common case — was therefore scoped by the engine and
invisible to the guard, and the driver did nothing with the scope: `tenantId`,
`tenantIds` and `organization_id` occurred nowhere in `memory-driver.ts`. Memory
runs returned cross-organization rows a SQL driver refuses, and neither driver
said a word.

`memory-tenant-scope.ts` is the read half, with `SqlDriver.applyTenantScope`'s
semantics reproduced arm for arm — equality or union, both keeping the #2734
NULL-tenant global-row carve-out. Every door that accepts a `DriverOptions`
routes through one chokepoint; `distinct()` accepts none and is named as the
one door that cannot.

Write-side stamping is deliberately not included, so the boot guard still
refuses a walled posture and an object declaring `tenancy.enabled: true`.
`declaresTenantScope`'s docstring is corrected in the same change: its
load-bearing sentence, "every object in a single-tenant deployment omits the
block", was false — `single` constrains the wall, not the number of
organizations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-memory, touching 23 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/drivers/driver-memory/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via createMany (sdk, the bare tail of client method data.createMany, bound to POST /api/v1/data/:object/createMany), data.createMany (sdk, the route ledger binds it to POST /api/v1/data/:object/createMany, selected by route anchor /:object/createMany), data.deleteMany (sdk, the route ledger binds it to POST /api/v1/data/:object/deleteMany, selected by route anchor /:object/deleteMany), data.updateMany (sdk, the route ledger binds it to POST /api/v1/data/:object/updateMany, selected by route anchor /:object/updateMany), deleteMany (sdk, the bare tail of client method data.deleteMany, bound to POST /api/v1/data/:object/deleteMany), updateMany (sdk, the bare tail of client method data.updateMany, bound to POST /api/v1/data/:object/updateMany))
  • content/docs/api/data-api.mdx (via deleteMany (symbol, a method of class InMemoryDriver), updateMany (symbol, a method of class InMemoryDriver), createMany (sdk, the bare tail of client method data.createMany, bound to POST /api/v1/data/:object/createMany), deleteMany (sdk, the bare tail of client method data.deleteMany, bound to POST /api/v1/data/:object/deleteMany), updateMany (sdk, the bare tail of client method data.updateMany, bound to POST /api/v1/data/:object/updateMany), /:object/createMany (route, bridged from symbol updateMany — its route source's handler names it), /:object/deleteMany (route, bridged from symbol deleteMany — its route source's handler names it), /:object/updateMany (route, bridged from symbol deleteMany — its route source's handler names it; bridged from symbol updateMany — its route source's handler names it))
  • content/docs/automation/hook-bodies.mdx (via deleteMany (symbol, a method of class InMemoryDriver), updateMany (symbol, a method of class InMemoryDriver), deleteMany (sdk, the bare tail of client method data.deleteMany, bound to POST /api/v1/data/:object/deleteMany), updateMany (sdk, the bare tail of client method data.updateMany, bound to POST /api/v1/data/:object/updateMany))
  • content/docs/automation/webhooks.mdx (via deleteMany (symbol, a method of class InMemoryDriver), updateMany (symbol, a method of class InMemoryDriver), deleteMany (sdk, the bare tail of client method data.deleteMany, bound to POST /api/v1/data/:object/deleteMany), updateMany (sdk, the bare tail of client method data.updateMany, bound to POST /api/v1/data/:object/updateMany))
  • content/docs/data-modeling/drivers.mdx (via InMemoryDriver (symbol, a top-level class), syncSchema (symbol, a method of class InMemoryDriver))
  • content/docs/permissions/authentication.mdx (via InMemoryDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via InMemoryDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/http-protocol.mdx (via deleteMany (symbol, a method of class InMemoryDriver), updateMany (symbol, a method of class InMemoryDriver), createMany (sdk, the bare tail of client method data.createMany, bound to POST /api/v1/data/:object/createMany), deleteMany (sdk, the bare tail of client method data.deleteMany, bound to POST /api/v1/data/:object/deleteMany), updateMany (sdk, the bare tail of client method data.updateMany, bound to POST /api/v1/data/:object/updateMany))
  • content/docs/protocol/kernel/lifecycle.mdx (via syncSchema (symbol, a method of class InMemoryDriver))
  • content/docs/protocol/knowledge.mdx (via deleteMany (symbol, a method of class InMemoryDriver), updateMany (symbol, a method of class InMemoryDriver), deleteMany (sdk, the bare tail of client method data.deleteMany, bound to POST /api/v1/data/:object/deleteMany), updateMany (sdk, the bare tail of client method data.updateMany, bound to POST /api/v1/data/:object/updateMany))
  • content/docs/protocol/objectql/query-syntax.mdx (via InMemoryDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via syncSchema (symbol, a method of class InMemoryDriver))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via InMemoryDriver (symbol, a top-level class), deleteMany (symbol, a method of class InMemoryDriver), syncSchema (symbol, a method of class InMemoryDriver), updateMany (symbol, a method of class InMemoryDriver), createMany (sdk, the bare tail of client method data.createMany, bound to POST /api/v1/data/:object/createMany), deleteMany (sdk, the bare tail of client method data.deleteMany, bound to POST /api/v1/data/:object/deleteMany), updateMany (sdk, the bare tail of client method data.updateMany, bound to POST /api/v1/data/:object/updateMany), /:object/createMany (route, bridged from symbol updateMany — its route source's handler names it), /:object/deleteMany (route, bridged from symbol deleteMany — its route source's handler names it), /:object/updateMany (route, bridged from symbol deleteMany — its route source's handler names it; bridged from symbol updateMany — its route source's handler names it))
  • content/docs/releases/v16.mdx (via InMemoryDriver (symbol, a top-level class), updateMany (symbol, a method of class InMemoryDriver), createMany (sdk, the bare tail of client method data.createMany, bound to POST /api/v1/data/:object/createMany), updateMany (sdk, the bare tail of client method data.updateMany, bound to POST /api/v1/data/:object/updateMany))
  • content/docs/releases/v17.mdx (via InMemoryDriver (symbol, a top-level class), deleteMany (symbol, a method of class InMemoryDriver), updateMany (symbol, a method of class InMemoryDriver), deleteMany (sdk, the bare tail of client method data.deleteMany, bound to POST /api/v1/data/:object/deleteMany), updateMany (sdk, the bare tail of client method data.updateMany, bound to POST /api/v1/data/:object/updateMany))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/drivers/driver-memory/src/index.ts) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ed7243d52bbc1b6d00a3b621b0dcea4925df32b5packageMentionDocs.

Which tree this was computed on

This run read content/docs from ebe843a28a9327e0ed40909b7549a64bcc59ba5c — the merge of head bb394b3fefb2d4b51dac9f1ca7f44af3f2d7f7e3 into base ed7243d52bbc1b6d00a3b621b0dcea4925df32b5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ebe843a28a9327e0ed40909b7549a64bcc59ba5c && git checkout ebe843a28a9327e0ed40909b7549a64bcc59ba5c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ed7243d52bbc1b6d00a3b621b0dcea4925df32b5 bb394b3fefb2d4b51dac9f1ca7f44af3f2d7f7e3 && git checkout -B drift-repro ed7243d52bbc1b6d00a3b621b0dcea4925df32b5 && git merge --no-ff bb394b3fefb2d4b51dac9f1ca7f44af3f2d7f7e3

node scripts/docs-audit/affected-docs.mjs --json ed7243d52bbc1b6d00a3b621b0dcea4925df32b5

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ed7243d52bbc1b6d00a3b621b0dcea4925df32b5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16733 @ 40300f0

Verdict: CHANGES REQUIRED

Ruling implemented: no maintainer ruling on the card — the dispatch's deliverable (triage option 2, 5572200390 / 5577356238) is implemented: yes. Whether that deliverable is itself authorised is F1.

Everything below was read independently of the PR body: issue #16589 and all five comments, #6915 / #8844 / #2734 / #3249 / #15212 / #16729 and their comments, origin/main (8ccf7a1df) for driver-sql and the gates, and the PR head fetched into a throwaway ref (merge-base 6ba0db4e0). Reads only — no checkout, no edits.

1. Ruling on the card

There is no ## Ruling recorded comment and no maintainer quote on #16589. The five comments are: triage (os-zhuang, MEMBER) grading bug / p2 and fixing the direction from the PM skill's standing meta-criterion (SKILL.md:721 — 「一个操作两个实现且行为不一致 ⇒ 带治理的一侧胜出」 ⇒ option 2, 「⛔ 不做选项 1」, option 3 as the floor); the PM claim (os-musk, COLLABORATOR) dispatching option 2; the dev report; its addendum; and the PM's round-received comment, which ends with 「this session has hit its Fable limit … it will not be landed on a lower-tier reading」 and names two things for this seat to attack (the changeset carriers and the write-side exclusion) — both answered below.

Standing rulings the PR was checked against:

2. Semantics parity with driver-sql

SqlDriver.applyTenantScope (sql-driver.ts:12488) vs tenantScopePredicate (memory-tenant-scope.ts), arm by arm:

arm SQL memory parity
tenantId undefined / null / '' builder untouched null predicate yes
no tenant column (resolveTenantField → null) untouched null predicate (tenantFieldByObject miss or tenantFieldOf null) yes
non-empty tenantIds (filter typeof === 'string' && !== '') whereIn(field, ids.map(String)).orWhereNull Set(String) membership or absent/null key yes
otherwise where(field, String(tenantId)).orWhereNull String(value) === String(tenantId) or absent/null key yes

Sticky opt-out: recordTenantField reproduces computeAndRecordTenantField (sql-driver.ts:9555-9565) line for line — a schema carrying tenancy sets/clears the opt-out and is computed; a schema without one returns null if opted out, else the resolver. Verified for READ scope only. The uniqueness path is untouched: memory-unique-constraint.ts is not in the diff and uniqueConstraintsFromFields still calls the non-sticky tenantFieldOf directly. #16729 exists, is open / bug / p3 / pm:queue, and says what the PR claims; its triage (5578291123) additionally notes the in-package asymmetry becomes real when this PR lands and names #16729 as this PR's closure, plus a driver-sql shard-path instance (sql-driver.ts:9479) of the same gap.

3. Chokepoint coverage

InMemoryDriver methods accepting DriverOptions on the head (15): find, findOne, create, update, upsert, delete, count, bulkCreate, updateMany, deleteMany, bulkUpdate, bulkDelete, aggregate, syncSchema, dropTable. this.tenantScope( call sites: 10 (find :596, update :741, upsert :780, delete :813, count :832, updateMany :903, deleteMany :952, bulkUpdate :1030, bulkDelete :1114, aggregate :1265 — one scope feeding both arms); findOne delegates to find with options (:678). That is the eleven doors named, and every door that selects rows by a predicate or an id is covered. Not routed: create, bulkCreate (insert doors — SQL routes these through injectTenantOnInsert, not applyTenantScope; deliberately excluded here), syncSchema, dropTable (DDL; SQL scopes neither). No read/update/delete door bypasses the chokepoint. The PR-body sentence "every door that accepts a DriverOptions routes through one chokepoint" is nonetheless literally false for those four — F4.

distinct(object, field, query?) (:1206) genuinely has no DriverOptions. IDataDriver in packages/spec/src/contracts/data-driver.ts declares no distinct member at all, and .distinct( over packages/*/src, apps, examples (non-test) matches only CHANGELOG/migration prose and the driver's own comment — no producer.

4. Boot guard

assertSingleTenantPosture and assertObjectsNotTenantScoped are byte-identical in logic on the head (walled postures refused; only explicit tenancy.enabled === true refused at syncSchema); call sites intact at constructor :423, connect :484, syncSchema :1984. memory-tenancy-guard.test.ts is not in the diff (18 cases including "the constructor refuses in multi-tenant mode" and "syncSchema() refuses a tenant-scoped object"). The read-half fix does not weaken the #6915 gate.

Docstring claim: packages/spec/src/security/tenancy-posture.ts defines posture as the Layer 0 wall (single → "none (inert)"; postureEnforcesWall is false for single only) and the engine's hasTenant (engine.ts:3925) has no posture term, so the scope is threaded under single. The spec's own ledger prose (error-code-ledger.zod.ts:645) already speaks of "a single posture whose data holds several organizations". "single constrains the wall, not the number of organizations" is supported. One nuance the corrected docstring could carry: the same spec table also calls single "one logical tenant", so the honest statement is that the posture treats them as one tenant while the engine still scopes by organization_id.

On the PM's second question (does the write-side exclusion hide a row a caller legitimately owns): no. A row is hidden only when it carries a different, non-null organization — identical to SQL. An unstamped own write lands org-less and is visible to its author (and to everyone). The residual is exposure of org-less rows, which is the #2734 semantics and what the corrected refusal message now names.

5. Files in the diff — governed paths: no

Six files, all under the merge-base three-dot diff: .changeset/memory-driver-read-side-tenant-scope.md, packages/drivers/driver-memory/src/{index.ts, memory-driver.ts, memory-tenancy-guard.ts, memory-tenant-scope.ts, memory-tenant-scope.test.ts}. None under docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**.

6. Changeset

Level: minor is correct and required twice over — batch #35 prose in pr-automation.yml:667-675 ("a new exported symbol on an index … takes at least minor"; three symbols are added) and the LEVEL axis in check-changeset-no-major.mjs:734ff (a Clause-②: yes declaration plus patch would be refused).

Carriers: the script's header is explicit that during the launch window "the mandatory information carriers for breaking-ness … are the BREAKING banner the author writes in the changeset body and the ADR-0087 migration-ledger disposition", and check-adr-0087-registration.mjs:51-54 fires only on a declared breaking change. No gate infers it, so the PR's reading of the gates is accurate. My own view on whether one is owed: a caller passing tenantId now receives fewer rows, and this package's own published docblock told that caller the driver "never reads DriverOptions.tenantId" — so the narrowed accept set was documented behaviour, not merely a defect. The closest released precedent is in this same package and on this same subject: #6915 / PR #7924 shipped as feat(driver-memory)!: with an ADR-0087 not-required (no-migration-prescription) disposition, on the reasoning "a refusal that was always owed, but it is still a behavior change, and the release notes must be able to say so" (driver-memory CHANGELOG, 17.x entry 45d5bd2). The precedent the PR cites (session-unbacked-org-claim-dropped.md, patch) is pending stock, as the PM already flagged. F3.

7. Tests

memory-tenant-scope.test.ts: the fixture seeds a1,a2 (A), b1 (B), g1 (org-less) on every object; the union case adds c1 (org_c) so [A,B] no longer covers the table. Every scoped case asserts the caller's own rows with a full toEqual id list (not merely absence). Forcing tenantScope to null reddens the binding control (['a1','a2','g1'] vs four rows), count (3 vs 4), both aggregate arms, the id-addressed doors and the many-row doors — the PR's 16/23 is plausible on inspection; CI on the head is green with the chokepoint live. tsconfig.json includes src/**/*, so the test file is in leg 1 of typecheck; tsconfig.typecheck.json covers objectstack.config.ts only, as the PR states. Missing pins: F2 and F5.

8. CI on 40300f01075578dba473fd87a34f17a68a893d69

37 check runs: 31 success, 6 skipped, 0 failure, 0 in_progress. Skipped: Auto Label, Check PR Size (one duplicate run each), Packed-tarball smoke (opt-in) ×2, Console Pin Gate, Build Docs. Nothing red or pending. mergeable_state: clean; draft; no reviews.

Findings

F1 — Direction reverses the last maintainer ruling on this subject without a recorded reversal. #6915's maintainer ruling (5261729371) chose Route B and wrote 「⛔ 不做处置 A(实现行级租户隔离)」; this PR implements the read half of A. Its authority is triage's standing meta-criterion plus a PM dispatch, both COLLABORATOR/MEMBER seats; the ground the maintainer gave (#5499) has since been dissolved by the maintainer, which makes reversal plausible but not recorded. Expectation for this PR: the PR body should stop describing this as "#6915's Route A" as if that route were sanctioned, and the merge waits for one maintainer line on #16589 (or #6915) confirming that row-level isolation on driver-memory is now permitted. Until then this is a maintainer-only merge — it is already outside the queue on needs:contract-review / Clause-② and should stay there.

F2 — upsert by explicit id across the wall now lands a second row with the same primary id (code change required). upsert (:770-806) scopes the conflict lookup to visible, so upsert(obj, { id: 'b1', … }, undefined, { tenantId: A }) where b1 belongs to B misses and falls to create, and create (:700-722) checks only declared unique constraints — id is not one (the package's own memory-bulk-create-atomicity.test.ts:181 records that the driver does not reject duplicate ids) — so the table now holds two rows with id === 'b1'. Before this PR the same call overwrote B's row (the defect); after it, the store carries a duplicate primary id, which then corrupts every id-addressed door for both tenants (update/delete take the first index; deleteMany's matchedIds set removes both). On driver-sql this cannot happen: INSERT … ON CONFLICT(id) merges on the primary key regardless of tenant (its own docblock: "the verdict itself is tenant-independent regardless: id is the PRIMARY KEY, so at most one row in the table can carry it") and only the readback is scoped (sql-driver.ts:7825). The PR-body claim "Matches driver-sql, which scopes its own upsert door" is therefore inaccurate — SQL scopes the readback, not the conflict target. The engine's only upsert producer (lifecycle-service.ts:1386, ['id'], no options) is unscoped and unaffected, so the radius is direct driver callers — but the PR explicitly promises "id-addressed doors land on their own existing 'not found' contract", and this door does not. Expectation: in the data.id arm, when a row with that id exists in the table but outside the scope, refuse (the existing not-found/strictMode contract or a UNIQUE_VIOLATION-shaped refusal — never a second row with the same id); add a test pinning that no table ever holds two rows with one id after a scoped upsert; correct the body sentence about SQL.

F3 — Changeset carriers: add the **BREAKING** banner and an ADR-0087 not-required disposition, or record why #7924 is not the governing precedent. The level is right; the carriers are author-declared; and the closest released precedent (same package, same subject, smaller radius) declared breaking and registered not-required (no-migration-prescription). The disposition text would be near-verbatim #7924's (no authorable surface retired; the affected consumer is reached by the changeset body and the corrected refusal message). Expectation: add both, or have the PM record on the card that the "bug fix aligning to the spec'd DriverOptions contract" reading is accepted in place of the #7924 precedent.

F4 — PR body and changeset overstate the chokepoint. "Every door that accepts a DriverOptions routes through one chokepoint" is false for create, bulkCreate, syncSchema, dropTable. The first two are the unstamped write doors the PR deliberately leaves open, and should be named as such next to the eleven; the last two are DDL. Expectation: reword to "every door that selects rows" and list the four exclusions.

F5 — Two pins missing from the suite. (a) The F2 case. (b) deleteMany under scope with a where (only the delete-all arm is exercised; the where arm filters visible then rebuilds by matchedIds, which is exactly where a duplicate id would cross tenants). Expectation: add both; the updateMany/deleteMany arms are the ones the PR itself calls "where the old silence cost the most".

Not findings, recorded so they are not re-derived: check-tenant-chokepoint.mjs cannot cover this driver (its criterion is getBuilder) — a coverage limit, accepted; distinct() unscoped with no producer — accepted as pinned; memory-analytics.ts's pipeline-arm call passes no options and is unscoped exactly as before — accepted.


Generated by Claude Code

…d of landing a duplicate primary id

Patch round on the contract review of PR #16733, per the director seat's
ruling comment 5579651209.

F2 (code): `upsert`'s `data.id` arm scoped its conflict lookup to `visible`,
so an id addressed across the tenant wall missed and fell through to
`create`. `create` checks only DECLARED unique constraints and `id` is not
one, so the table ended up holding two rows with one primary id, which then
corrupts every id-addressed door for both tenants. It now refuses on this
driver's own existing "not found" contract. `driver-sql` is not the
precedent for falling through: it merges on the PRIMARY KEY regardless of
tenant and scopes only the readback, so the duplicate is unreachable there.
The code comment claiming otherwise is corrected.

F5 (pins): the F2 case, with merge-inside-scope and insert-of-a-fresh-id as
positive controls; and `deleteMany` under scope WITH a `where`, which takes
the matched-id rebuild path rather than the delete-all path.

F3 (changeset carriers): the `**BREAKING**` banner and the ADR-0087
`not-required (no-migration-prescription)` disposition, following the same
package's released precedent PR #7924. Level stays `minor`.

F4 (wording): the chokepoint claim is "every door that selects rows", with
`create`, `bulkCreate`, `syncSchema` and `dropTable` named as the exclusions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH

Copy link
Copy Markdown
Contributor

Patch round — F1–F5 (director seat ruling 5579651209)

Head 40300f010bb394b3fefb2d4b51dac9f1ca7f44af3f2d7f7e3 (bb394b3fef). origin/main was merged in first with a plain merge (9edef33ca); ⛔ no rebase, no amend, no force-push. Still draft, labels untouched, nothing marked ready or queued.

Delivered exactly the five items the ruling names, and nothing else.

F2 — code: upsert across the wall refuses instead of landing a duplicate primary id

memory-driver.ts, the data.id arm. The conflict lookup was scoped to visible, so an id belonging to another organization missed and fell through to create — and create checks only DECLARED unique constraints, of which id is not one, so the table ended up holding two rows with one primary id. It now refuses on this driver's own existing "not found" contract, the same message update and delete throw for a cross-tenant id.

The refusal fires in strictMode and outside it alike, and the comment says why rather than leaving it to be discovered: unlike update (null) and delete (false), upsert's declared return carries no miss arm (#13878), so the quiet non-strictMode miss is not expressible here. The alternatives were widening the door's declared return or landing the duplicate; both are worse than throwing.

The code comment claiming driver-sql as the precedent for falling through is corrected in place. SQL merges on the PRIMARY KEY regardless of tenant and scopes only the readback, so the duplicate is unreachable there — it is not a precedent for this store, which has no such key.

F5 — the two missing pins

memory-tenant-scope.test.ts, 23 → 25 cases.

  • The F2 case. Refuses, and the table still holds exactly one row carrying b1 — the assertion the finding is about — with the other organization's row unchanged. Two positive controls sit beside it so the pin reads as "the cross-wall id is refused" rather than "upsert by id is broken": inside the organization the same door still MERGES, and an id no row carries still INSERTS.
  • deleteMany under scope WITH a where. Only the delete-all arm was exercised before. The where arm filters visible, collects matchedIds, then rebuilds the whole table from that set. The filter matches exactly one row in each organization, so "scoped" and "unscoped" are two different numbers (1 vs 2), not two row lists that happen to differ.

F3 — changeset carriers

Added to .changeset/memory-driver-read-side-tenant-scope.md: the BREAKING banner in the double-asterisk spelling the gates match, and the ADR-0087 disposition marker claiming not-required (no-migration-prescription), spelled as check-adr-0087-registration.mjs parses it. Level stays minor — unchanged. Precedent cited is #6915 / PR #7924, the same package on the same subject; the pending-stock session-unbacked-org-claim-dropped.md reading is withdrawn in the PR body.

The gate is the evidence, not my say-so: before this round it had no declaration to judge, and it now judges one and passes it (output quoted below).

F4 — chokepoint wording

PR body and changeset both now say "every door that selects rows", and both name the four exclusions: create and bulkCreate (insert doors — driver-sql routes those through injectTenantOnInsert, not applyTenantScope, and the write half left out of this change is exactly the stamp they would need) and syncSchema / dropTable (DDL, which driver-sql does not scope either).

F1 — authority

The PR body opens with an Authority section that cites ruling 5579651209 and quotes it: "option 2 stands: read-side row-level tenant scope on driver-memory is permitted". It states plainly that #6915's maintainer ruling 5261729371 declined Route A — 「⛔ 不做处置 A(实现行级租户隔离):#5499 投资冻结继续有效」 — that the stated ground (#5499) was dissolved on 2026-08-11, and that the earlier "the read half of #6915's Route A" phrasing presented a declined route as an approved one. ⛔ #6915 is no longer offered as sanctioning this.

Verification on bb394b3fef, verbatim

pnpm --filter @objectstack/driver-memory test
   Test Files  45 passed (45)
        Tests  1120 passed | 15 todo (1135)
   exit 0

pnpm --filter @objectstack/driver-memory typecheck
   tsc --noEmit && tsc --noEmit -p tsconfig.typecheck.json
   exit 0

1118 → 1120 is the two F5 pins; the 15 todo are unchanged analytics date-range items, none in the tenancy path. Both runs went through scripts/pm/os-verify-lock.sh; its VERDICT command-exit 0 line is what the exit codes above are read from, not a bare $?.

The three changeset gates, each --base origin/main:

node scripts/check-empty-changeset.mjs --base origin/main            EXIT=0
  Diffing HEAD from ed7243d52 (merge base with origin/main).
  ✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).

node scripts/check-changeset-no-major.mjs --base origin/main         EXIT=0
  ✓ This diff introduces no `major` bump.
  ℹ️ LEVEL AXIS: NOT MEASURED — no clause-② declaration was readable for this PR ...
     · no `pull_request` payload was available to read a declaration from

node scripts/check-adr-0087-registration.mjs --base origin/main      EXIT=0
  ✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.
      .changeset/memory-driver-read-side-tenant-scope.md  [BREAKING]  not-required (no-migration-prescription)

⚠️ Read the middle one honestly: its LEVEL axis is NOT MEASURED locally, because a local run has no pull_request payload to read the clause-② declaration from. That is neither a pass nor a failure (#4690) — CI runs it with the payload and is the reading that counts for that axis. The major axis itself is green.

Scope kept, and what was NOT re-measured

Three files changed this round: memory-driver.ts, memory-tenant-scope.test.ts, the changeset. ⛔ Nothing widened — no label writes, no ready flip, no touch to memory-tenancy-guard.ts, memory-tenant-scope.ts or index.ts, and no new findings filed.

⚠️ The repository-wide pnpm lint, the four consumer suites and the 56-family gate sweep in the PR body were measured on 40300f0107 and are not re-measurements of this head. They are labelled as such in the body rather than restated as current; CI on bb394b3fef is the run that answers for them.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16733 @ bb394b3 — re-review

Verdict: PASS WITH FINDINGS — conditional on the six in-progress CI jobs finishing green (item 7)

All five prior findings (F1–F5) are discharged on bb394b3. The one residual (R1) is board state, not code: the ruling this PR is reviewed against and the PM's later needs-user-decision post sit side by side on #16589 unreconciled. No code change is asked for.

Re-review of head bb394b3fefb2d4b51dac9f1ca7f44af3f2d7f7e3 against the prior review at 40300f0 (5578985922, CHANGES REQUIRED, F1–F5) and the patch-round claim (5579814860). Everything below was read independently of both: the PR head fetched into a throwaway ref (refs/review/16733b, merge-base with origin/main = ed7243d52), git show / git diff only — no checkout, no edits, no stash; the three changeset gates run read-only against that ref from the primary checkout's scripts/; #16589's seven comments and the PR's three read over MCP.

The ruling this round rests on

F1 is discharged by the director seat's ruling on the card, #16589 comment 5579651209 (os-zhuang, decision batch #84, 2026-09-08 05:15Z). Quoted:

Ruling recorded — option 2 stands: read-side row-level tenant scope on driver-memory is permitted (director seat, decision batch #84, 2026-09-08)

Ruled. The #6915 exclusion of A rested on one stated ground, the #5499 freeze, which the maintainer dissolved on 2026-08-11 (5252526378, "fully dissolved … no longer gates anything"). With the ground gone, the standing meta-criterion decides (SKILL.md: 「一个操作两个实现且行为不一致 ⇒ 带治理的一侧胜出」): the governed side is driver-sql's enforced scope, so driver-memory's read path aligns to it — triage option 2 as dispatched. The #6915 boot guard is not weakened: walled postures and tenancy.enabled: true stay refused; write-side stamping stays out […]. This is not a reversal of #6915's purpose (no silent non-isolation), it removes the one silence it left.

Execution for PR #16733 before landing: F2 (code, required) […] refuse on the existing not-found / strictMode contract — never fall through to create and never leave two rows with one primary id. Pin it. […] F3 (changeset): add the **BREAKING** banner and <!-- adr-0087: not-required (no-migration-prescription) -->, following the same package's released precedent #7924; level stays minor. F4/F5: reword the chokepoint claim to "every door that selects rows" naming create / bulkCreate / syncSchema / dropTable as the exclusions; add pins for the F2 case and for deleteMany under scope with a where. Then re-review at tier; non-governed, the director seat lands it.

Recorded for the ledger rather than re-litigated: the PM's later needs-user-decision post (5579692717, 05:20Z) argues the opposite reading of #6915 and asks the maintainer for A/B/C. It post-dates the ruling by five minutes and does not cite it; the ruling's provenance line is a maintainer delegation (「你应该自主处理完」 / 「自主继续呈报」), so on the board as it stands the ruling governs and this seat reviews against it. Whether the PM's question stays open beside it is not this seat's call — see the residual at the end.

Prior findings — discharged?

# Finding at 40300f0 Head bb394b3 Discharged
F1 Direction reversed #6915's 「不做处置 A」 with no recorded reversal; body presented Route A as sanctioned Ruling 5579651209 recorded on the card; PR body opens with an Authority section that quotes it verbatim, quotes #6915's 「⛔ 不做处置 A」, names the earlier "read half of #6915's Route A" phrasing as the error, and says ⛔ #6915 is not offered as sanctioning this yes
F2 Scoped upsert by a foreign-tenant id fell through to create → two rows, one primary id data.id arm now refuses before any write when the id exists in table but not in visible (memory-driver.ts:809-812); same message as update/delete; code comment correcting the driver-sql claim yes
F3 No **BREAKING** banner, no ADR-0087 disposition; cited pending-stock precedent Both carriers present in the spelling both gates parse; level minor; precedent now #7924; the session-unbacked-org-claim-dropped.md reading withdrawn in the body yes
F4 "Every door that accepts a DriverOptions" — false for four Changeset and body both say "every door that selects rows" and name create / bulkCreate (insert, injectTenantOnInsert territory) and syncSchema / dropTable (DDL) as the exclusions yes
F5 Pins missing: the F2 case; deleteMany under scope with a where Two cases added, 23 → 25 (grep -c on it( = 25) yes

Verification, numbered

  1. Files and governed paths. git diff ed7243d52..bb394b3f --stat: exactly six files — .changeset/memory-driver-read-side-tenant-scope.md, packages/drivers/driver-memory/src/{index.ts, memory-driver.ts, memory-tenancy-guard.ts, memory-tenant-scope.ts, memory-tenant-scope.test.ts} (891+/41−). grep over the name list for content/docs, packages/spec, adr, releases, .github: none. The head is 40300f0 + a plain merge of origin/main (9edef33ca, parents 40300f01 and ed7243d52) + one commit; the patch-round-only diff 9edef33ca..bb394b3f touches three files (changeset 8 lines, driver 34, test 60) — matches the claim.

  2. The upsert data.id arm (memory-driver.ts:770-836). Read arm by arm:

    • foreign id refuses before any write: existingRecord = visible.find(r => r.id === data.id); then if (!existingRecord && table.some(r => r.id === data.id)) throw new Error('Record with ID <id> not found in <object>') — the throw sits before the update/create branch, so nothing is written. When scope is null, visible === table, the some can only be true when find already hit, so the refusal is unreachable on the unscoped path — unscoped callers are unchanged.
    • in-scope merge still updates: existingRecord visible → this.update(object, existingRecord.id, data, options); update resolves through the same scope (:741), the row is visible, so it cannot miss.
    • fresh id still inserts: !existingRecord && !table.some(...) → falls to this.create(object, data, options).
    • no path leaves two rows with one id: the three outcomes above are exhaustive on the id arm; the only insert is the one where no row in the whole table carries the id.
    • non-strict-mode behaviour: the declared contract is IDataDriver.upsert(...): Promise<Record<string, unknown>> (packages/spec/src/contracts/data-driver.ts:203) and the driver's signature matches it — no null / false miss arm, unlike update (Promise<Record<string, unknown> | null>, :732) and delete (Promise<boolean>, :209 in the contract). Throwing in and out of strictMode is therefore the only expressible refusal short of widening the published type; the asymmetry is stated in the code comment. Defensible.
    • bulkUpdate / updateMany do not share the fall-through. bulkUpdate (:1050ff) resolves every id through the scope (table.findIndex((r) => r.id == u.id && (!scope || scope(r)))); a -1 takes its own missing-id arm (strict: throw before any write; otherwise skip) and the method has no insert path at all. updateMany (:923ff) draws targetRecords from the scoped table and only ever rewrites indexes it resolved; no insert path either. bulkDelete (:1141ff) is the same shape as bulkUpdate.
  3. The two new pins (memory-tenant-scope.test.ts, patch-round diff read in full; fixture a1/a2org_a, b1 = 'B one'org_b, g1 org-less, ids() sorts):

    • F2 pin (:393-430): upsert(obj, { id: 'b1', name: 'hijacked' }, undefined, { tenantId: ORG_A }) rejects.toThrow(/Record with ID b1 not found/); then after.filter(r => r.id === 'b1')).toHaveLength(1) — exactly one row carries the id — and after.find(r => r.id === 'b1')).toMatchObject({ name: 'B one', organization_id: ORG_B }) — the foreign row is unchanged; table still ['a1','a2','b1','g1']. Two positive controls: a1 in-scope merges (name: 'renamed'), fresh a3 inserts, final table ['a1','a2','a3','b1','g1'].
    • deleteMany pin (:344-363): where name contains 'one' matches exactly a1 and b1; scoped to ORG_A the return is asserted toBe(1) (unscoped would be 2) and the survivors ['a2','b1','g1'] — the discrimination is by count, as required.
    • I could not run the suite from this seat (the primary checkout carries no node_modules for the package and installing is not a read-only act); the colour on the head is CI's Test Core, item 7.
  4. Changeset (.changeset/memory-driver-read-side-tenant-scope.md at head):

    • frontmatter '@objectstack/driver-memory': minor — unchanged;
    • line 5 opens **BREAKING** — a caller that passes …; check-adr-0087-registration.mjs:572 reads breaking-ness with /\*\*BREAKING/i on the body — matches;
    • last line <!-- adr-0087: not-required (no-migration-prescription) Nothing is retired or renamed … #6915 / PR #7924. -->; the gate's marker regex is /<!--\s*adr-0087\s*:\s*([\s\S]*?)-->/g (:1488, exactly one expected, :1491) and the category regex /adr-0087\s*:\s*not-required\s*\(\s*([a-z-]+)/g (:1547) — matches, category no-migration-prescription, which the gate re-validates by refusing a body that carries a migration prescription;
    • chokepoint sentence: "Every door that selects rows routes through one chokepoint: …" followed by "Four doors that take a DriverOptions are deliberately not routed through it: create and bulkCreatesyncSchema and dropTable …" — the four exclusions named;
    • a paragraph on the upsert refusal and the corrected driver-sql comparison is present.
    • Gates run read-only against the ref from this seat (--base ed7243d52 --head refs/review/16733b): check-adr-0087-registration exit 0 — 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING] not-required (no-migration-prescription); check-changeset-no-major exit 0 — "This diff introduces no major bump"; check-empty-changeset exit 0 — 1 declaring changeset(s) added. On CI the first and third run inside job changeset-check / Check Changeset (pr-automation.yml:193, steps at :784 and :825) — success on this head.
  5. PR body. Opens with ## Authority — the director seat's ruling, ⛔ not #6915's Route A; cites 5579651209 by number and quotes "option 2 stands: read-side row-level tenant scope on driver-memory is permitted" — verbatim against the ruling's title; quotes driver-memory 完全没有行级租户隔离(#3724 的未修姊妹面):多租户下静默不隔离 #6915's 「⛔ 不做处置 A(实现行级租户隔离):[裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499 投资冻结继续有效」 as the declined route; states the earlier phrasing "presented a declined route as an approved one". The driver-sql upsert comparison is corrected under a ⛔ heading: SQL "merges on the PRIMARY KEY regardless of tenant … and only the readback is scoped". The "Matches driver-sql, which scopes its own upsert door" sentence is gone from the code comment (9edef33ca..bb394b3f diff shows its removal). The withdrawn pending-stock precedent is marked ⛔ Withdrawn. Fixes #16589 + Clause-②: yes as bare lines; still draft, needs:contract-review still hung.

  6. Unchanged files since 40300f0. Blob ids: memory-tenancy-guard.ts e0e630c6… = e0e630c6…; memory-tenant-scope.ts 0a162919… = 0a162919…; index.ts ae976518… = ae976518…. Identical at both commits, and origin/main between the two merge-bases touched nothing under packages/drivers/driver-memory.

  7. CI on bb394b3f. Measured at this seat's last poll (05:39Z; the run started 05:31Z): 34 check runs — 25 success, 3 skipped, 6 in_progress, 0 failure. Skipped: Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in) — the same three that were skipped on 40300f0. In progress: Test Core (4/6), Test Core (5/6), Dogfood Regression Gate (1/3), (2/3), (3/3), Lint & Repo Gates. Green and relevant to this diff: Build Core, Test Core (1/6, 2/6, 3/6, 6/6), Type Check · workspace / source gates / consumer gates / debt ledger, TypeScript Type Check, Check Changeset, Governed Surface Queue Guard, Temporal Conformance, Dogfood Verify CLI, the three single-writer / same-issue / part-of guards. ⚠️ The verdict above is conditional: it stands only if the six in-progress jobs complete green. Whichever Test Core shard carries packages/drivers/driver-memory is the one that colours the 25-case suite on this head (this seat could not run it locally — item 3); if any of the six goes red, treat this as CHANGES REQUIRED until read. The director seat reads CI itself before landing; mergeable_state is blocked (draft + pending checks), as expected.

Residual findings

R1 — Two contradictory board posts on #16589, five minutes apart, and neither cites the other (board hygiene, not code). The ruling 5579651209 (05:15Z) says "option 2 stands … non-governed, the director seat lands it". The PM's 5579692717 (05:20Z) moves the card pm:dispatchedneeds-user-decision, says "PR #16733 stays draft and must not land until a maintainer line settles the question", and asks the maintainer for A/B/C with A (= void this PR) recommended. Both are on the card now. This seat reviews against the ruling because it carries a maintainer delegation in its provenance line and the PM's post does not claim to override it — but a future reader sweeping the card meets the later post first. Expectation before landing: one line on #16589, from the director seat, stating that 5579651209 governs and 5579692717's question is closed by it (or, if the director seat wants the maintainer's A/B/C anyway, that the landing waits for it) — and the card's needs-user-decision label reconciled accordingly. Not a change to this PR; recorded here because "anything that still blocks landing" has to include it.

Recorded, not findings (so they are not re-derived): (a) the id arm compares with === (visible.find and table.some) while update/delete use == — a caller passing numeric 1 for a stored '1' falls to create exactly as it did before this PR; pre-existing, not tenancy-related, out of scope. (b) create / bulkCreate still accept an explicit data.id that already exists in the table (pinned as accepted by memory-bulk-create-atomicity.test.ts), so a duplicate primary id remains reachable through the insert doors this PR deliberately leaves unscoped; under scope, updateMany then resolves record.id by first index across the whole table (:958). Only reachable through a pre-existing, accepted state — not introduced here, not a blocker; a card if anyone wants id uniqueness enforced on this store. (c) The conflictKeys arm still inserts when the only matching row sits outside the scope; that insert carries a fresh id, so it cannot produce a duplicate primary id, and it was accepted at the prior review. (d) distinct() unscoped with no producer, check-tenant-chokepoint.mjs coverage limit, memory-analytics.ts pipeline arm unscoped as before — all accepted at 40300f0, unchanged.

⛔ This seat did not approve, label, mark ready, or edit anything; the PR stays draft with needs:contract-review hung, as the ruling's "non-governed, the director seat lands it" line requires.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Closed unmerged — maintainer ruling A on #16589 (decision batch #85, 2026-09-08)

Maintainer, verbatim: 「16589 内存驱动不需要支持多租户,业务上没有任何意义啊」. The memory driver does not implement tenant isolation; #6915's ruling B (loud refusal instead of silent non-isolation) stands and is the card's remaining deliverable. This PR's read-side scope, its F2–F5 patch round (bb394b3f, re-review PASS WITH FINDINGS 5579888194, CI green) and the engine seat's stand-down (5579692717) are all recorded on #16589 and on the ledger objectstack#12708. The declaresTenantScope docstring correction and the corrected refusal message in this branch are worth carrying into the guard-shape PR.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

3 participants